Skip to content

Keep idle SSH tunnel sessions alive on dedicated clusters - #6382

Open
anton-107 wants to merge 1 commit into
implement-deco-28186-spec-in-comment-do-not-stop-6from
deco-28186-ssh-level-keepalive
Open

Keep idle SSH tunnel sessions alive on dedicated clusters#6382
anton-107 wants to merge 1 commit into
implement-deco-28186-spec-in-comment-do-not-stop-6from
deco-28186-ssh-level-keepalive

Conversation

@anton-107

@anton-107 anton-107 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Stacked on #6358: the base branch is that PR's branch, so this diff is only the change on top of it. GitHub retargets to main when #6358 merges.

Changes

#6358 fixes idle-session drops on serverless and does nothing for a dedicated cluster. Its websocket ping is a control frame: the driver proxy terminates it itself, so it never becomes payload on the leg past it, and that leg is reaped after ~8m20s of carrying nothing. Zero pongs came back in any run on either compute type — the frame does not traverse. Serverless sits behind a different reaper that the ping does reset, which is why it fixes serverless and only serverless.

The fix is to generate traffic the tunnel forwards end to end. An SSH keepalive reply is a real SSH packet, so the proxy loops carry it as a websocket data frame across every hop — which is why setting ServerAliveInterval by hand is a total workaround. The CLI now asks for it itself, from both ends:

  • ServerAliveInterval 30 in buildSSHArgs (the connect path) and GenerateHostConfig (the setup and --ide paths). Needs nothing from the compute, so it also fixes a cluster already running a server binary from an older CLI.
  • ClientAliveInterval 30 in the sshd config the tunnel's server writes. Covers clients the CLI never configures — a hand-written ProxyCommand block, or an IDE supplying its own ssh options.

Either half alone is sufficient (measured below). Both are in because they close different gaps and neither subsumes the other. The websocket ping stays: it is what fixes serverless, and it keeps the client↔control-plane hop warm regardless of the user's ssh config.

One behaviour change, deliberately. These options bring in ServerAliveCountMax / ClientAliveCountMax (OpenSSH default 3), so a tunnel that stops responding is now torn down after ~90s with ssh's own "server not responding" message instead of hanging. That is the trade we want — today a reaped idle session is a silent black hole, which is the original report — and 90s is well clear of the longest legitimate pause on a healthy tunnel, the up to 30s a handover can hold the sending loop (proxyHandoverInitTimeout).

sshdConfigContent is prepareSSHDConfig's config string moved verbatim into a function plus the one new line, so the content is testable without mocking three secret reads. #6358's changelog fragment is updated rather than joined by a second, contradicting one.

An out-of-band keepalive frame of our own was rejected: runReceivingLoop writes every binary message straight into the SSH byte stream, so it needs a framing change on both ends and only helps once the uploaded server binary is new enough.

Resolves DECO-28186 for dedicated clusters; #6358 resolves it for serverless.

Why

Dedicated compute is what the README requires for remote development in an IDE, so the compute type most affected was the one #6358 left broken. Shipping only the websocket ping would close the ticket with the reported failure still in place for those users, and add a log line that makes an idle session look healthy while it is dying.

Tests

Three unit tests, one per site the directive has to appear in, each also bounding the interval against the reap window and against the handover pause. All six mutants killed: dropping the option from each of the three sites, setting either constant to 0, and pushing the interval past the reap window.

./task test-exp-ssh, ./task lint (0 issues, all three modules), ./task fmt, and the whitespace, deadcode and changelog checks are all clean; the four touched packages are green under -race.

End-to-end (dogfood, dedicated clusters)

One dedicated single-node cluster per build — two tunnels on one cluster collide on port 7772 and take each other down. DBR 17.3 LTS, m5d.xlarge, SINGLE_USER; distinct pinned version per build, since uploadReleases skips the upload when the versioned workspace path already exists. Every session ran on a real PTY, was left completely untyped for the whole window, then sent exactly one command. Idle is measured remote-clock to remote-clock.

run build cli ServerAliveInterval sshd ClientAliveInterval idle pings/pongs verdict
dc-base #6358 head none none died at +8m20s 30 / 0 FAIL
dc-fix this PR 30 30 613 s 32 / 0 PASS
dc-sshd-only this PR 0 (forced off) 30 655 s 34 / 0 PASS
dc-fix-long this PR 30 30 1194 s 61 / 0 PASS
  • dc-base reproduces the bug on Keep idle SSH tunnel sessions alive with a websocket ping #6358's own head, as reported: alive-looking for the whole window, killed by the first keystroke. The server lost its half at 18:04:46 — 8m20s after the session went quiet — with websocket: close 1006 (abnormal closure): unexpected EOF. The client had sent 30 pings with zero failures by then; its next two failed, after the server was already gone. Ended close 4000: Handler crashed: ...ClosedStreamException, exit status 255.
  • dc-sshd-only isolates the server half: same build, but ssh invoked by hand with -o ServerAliveInterval=0, so sshd drives the only SSH-level keepalive. A command-line -o beats any config file in OpenSSH, and ClientAliveInterval 30 was read back off the cluster's generated sshd_config. The complementary control for the client half — old server binary, client-side option only — passed earlier on the same setup.
  • dc-fix-long is past two reap windows, so the PASS is a real fix rather than a delayed drop.

Full measurements and log excerpts are on DECO-28186. The test clusters have been terminated.

This PR and its description were written by Isaac.

The websocket keepalive ping never reaches a dedicated cluster: the driver
proxy answers control frames itself, so the tunnel leg past it stays idle and
is reaped after ~8m20s. Generate real SSH payload instead, from both ends of
the tunnel — ServerAliveInterval on the ssh client the CLI spawns and in the
host config it writes, and ClientAliveInterval in the sshd config the tunnel's
server writes.

Co-authored-by: Isaac <no-reply@databricks.com>
@anton-107
anton-107 changed the base branch from main to implement-deco-28186-spec-in-comment-do-not-stop-6 August 25, 2026 18:32
@github-actions

Copy link
Copy Markdown
Contributor

Waiting for approval

Based on git history, these people are best suited to review:

  • @ilia-db -- recent work in experimental/ssh/cmd/, experimental/ssh/internal/client/, experimental/ssh/internal/proxy/

Eligible reviewers: @andrewnester, @denik, @janniklasrose, @lennartkats-db, @pietern, @rclarey, @renaudhartert-db, @rugpanov, @shreyas-goenka, @simonfaltum

Suggestions based on git history. See OWNERS for ownership rules.

@eng-dev-ecosystem-bot

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 4fe97ea

Run: 32884666064

Env 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
💚​ aws linux 1 4 274 1180 5:49
💚​ aws windows 1 4 276 1178 4:23
💚​ azure linux 1 4 273 1180 4:38
💚​ azure windows 1 4 275 1178 3:36
💚​ gcp linux 1 4 274 1180 5:34
💚​ gcp windows 1 4 276 1178 5:01
Test Name aws linux aws windows azure linux azure windows gcp linux gcp windows
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🙈​ TestAccept/bundle/invariant/no_drift 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_endpoints/drift/recreated_same_name 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_indexes/recreate/embedding_dimension 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
Top 3 slowest tests (at least 2 minutes):
duration env testname
4:58 gcp windows TestAccept
4:20 aws windows TestAccept
3:15 azure windows TestAccept

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants